#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
对 微信、腾讯会议、瞩目 的操作都在这个脚本里。
"""

import os, subprocess, re, time, psutil

import numpy as np, pyperclip
import win32gui, win32api, win32con, win32print, win32process
from pynput import mouse, keyboard
from PIL import ImageGrab

try:
    from getcfg import yaml, get_config_paths, OtherConfig
except ImportError:
    from .getcfg import yaml, get_config_paths, OtherConfig


class AutoCIError(Exception):
    "此脚本释放的异常子类。"

class AppError(AutoCIError):
    '关于应用操作的错误。'
class NotFoundError(AutoCIError):
    '未找到。'

def mouse_click_delay(second = None):
    "鼠标移动到目标位置后，在点击操作前的延迟函数。"
    if second is None:
        second = other_config["delay duration"]["mouse click"]
    time.sleep(second)
def keyboard_type_delay(second = None):
    "按下键盘按键的延迟。"
    if second is None:
        second = other_config["delay duration"]["keyboard type"]
    time.sleep(second)
def button_clicked_delay(second = None):
    "按下按键后，等待程序响应的函数。"
    if second is None:
        second = other_config["delay duration"]["button clicked"]
    time.sleep(second)

def get_click_pos(times = 1, timeout = 4):
    """
    获取鼠标点击（仅限按下）的位置。若超过timeout秒未检测到点击，则释放错误。
    times为点击的次数。
    
    返回：
        times : =1  tuple (pos.x, pos.y)
              : >1  list [(pos.x, pos.y), ...]
    """
    last = time.time()
    ret  = []
    with mouse.Events() as events:
        while times:
            event = events.get(timeout)
            if isinstance(event, mouse.Events.Click):
                if event.pressed:
                    ret.append((event.x, event.y))
                    times -= 1
                    last = time.time()
            else:
                if time.time() - last > timeout:
                    raise TimeoutError
    if len(ret) == 1:
        return ret[0]
    else:
        return ret

def click(rx, ry = None, button = "left"):
    """鼠标点击。
    button 应为 "left"、"right"、"middle"中的一个或'pynput.mouse.Button'的值。
    """
    if ry is None:
        rx, ry = rx
    with mouse.Controller() as ctrl:
        ctrl.position = (rx, ry)
        mouse_click_delay()
        ctrl.click(getattr(mouse.Button, button, button))
def click_from_pixel_pos(rx, ry = None, button = "left"):
    """鼠标点击。位置不是屏幕坐标，而是像素坐标，适用于应用缩放屏幕的情况。
    button 应为 "left"、"right"、"middle"中的一个或'pynput.mouse.Button'的值。
    """
    if ry is None:
        rx, ry = rx
    mul = sys_screen.screen_hight / sys_screen.screen_pixel_hight
    click(rx*mul, ry*mul, button)

def type_key(*keys):
    "按下按键。key值应为pynput.keyboard.Controller支持的内容。"
    ctrl = keyboard.Controller()
    with ctrl.pressed(*keys):
        pass

other_config = OtherConfig()

class SystemScreen:
    '''
    屏幕相关操作，包括获取屏幕信息，截屏等。
    windows only，开始菜单栏必须在底部且为白色
    '''
    hDC = win32gui.GetDC(0)
    @property
    def screen_width(self):
        "显示器的宽度（分辨率）"
        return win32api.GetSystemMetrics(win32con.SM_CXSCREEN)
    @property
    def screen_hight(self):
        "显示器的高度（分辨率）"
        return win32api.GetSystemMetrics(win32con.SM_CYSCREEN)
    @property
    def app_full_width(self):
        '应用区域最大宽度（分辨率）'
        return win32api.GetSystemMetrics(win32con.SM_CXFULLSCREEN)
    @property
    def app_full_hight(self):
        '应用区域最大高度（分辨率）不知道为什么就是不准'
        return win32api.GetSystemMetrics(win32con.SM_CYFULLSCREEN)
    @property
    def screen_pixel_width(self):
        "显示器像素宽度。"
        return win32print.GetDeviceCaps(self.hDC, win32con.DESKTOPHORZRES)
    @property
    def screen_pixel_hight(self):
        "显示器像素高度。"
        return win32print.GetDeviceCaps(self.hDC, win32con.DESKTOPVERTRES)
    
    def get_window_rect(self, hwnd, pixel=True):
        """调用win32gui获取矩形窗口的坐标。结果稍大。单位：分辨率/像素。
        输入：
            pixel: bool 默认是，返回像素单位的坐标。
                        否，返回分辨率单位坐标。
        返回：
            tuple: int (左上x, 左上y, 右下x, 右下y)
        注：
            解决结果稍大的方法：
            https://blog.csdn.net/muslim377287976/article/details/90730214"""
        r = win32gui.GetWindowRect(hwnd)
        if not pixel:
            return r
        else:
            mul = round(self.screen_pixel_hight / self.screen_hight, 2)
            return tuple(mul*i for i in r)
    
    def pixel_to_resolution(self, pixel):
        "将像素高度转换为分辨率高度。"
        return round(self.screen_hight / self.screen_pixel_hight * pixel)
    def screenshot(self):
        "截取全屏。"
        #另一种方式（来自 https://www.cnblogs.com/danvy/p/11721087.html）：
        #win32api.keybd_event(win32con.VK_SNAPSHOT, 0)#按下print screen 键
        #return ImageGrab.grabclipboard()#读取剪切板。
        return ImageGrab.grab()

sys_screen = SystemScreen()

class ChatRecord:
    """聊天记录。由于技术原因，无法获取表情信息。
    Attributes:
        message : list [[name1, msg],
                        [name2, msg], ...]
        names : set {name1, name2, ...}
        text : list 按行分割后的聊天记录原文
    """
    _re_friend_name = re.compile(r"(.+):$")
    def __init__(self, text, name=None):
        '''text : str 复制来的聊天文本。
           name : None / str / iterable
                  昵称。None表示无信息，str表示填入单个昵称，iterable填入其元素。
        '''
        #准备
        msg = []
        self.names = names = set()
        if name:
            if isinstance(name, str):
                names.add(name)
            else:
                names.update(set(name))
        self.text = text = text.splitlines()
        key_text = {f"{n}:" for n in names}
        
        #开始整理
        last = [text[0]]
        if m := self._re_friend_name.match(text[0]):
            names.add(m.group(1))
            key_text.add(text[0])
        #迭代记录
        for line in text[1:]:
            if line in key_text:
                if last and last[-1] == "":
                    msg.append(last[:-1])
                    last.clear()
            elif m := self._re_friend_name.match(line):
                names.add(m.group(1))
                key_text.add(line)
                if last and last[-1] == "":
                    msg.append(last[:-1])
                    last.clear()
            last.append(line)
        if len(last) > 1:
            msg.append(last)
        try:
            self.message = message = [[msg[0][0], msg[0][1:]]]
        except IndexError:
            self.message = []
            return
        for a, *b in msg[1:]:
            if a not in key_text:
                message[-1][1].extend(b)
            elif len(b) < 1 or (not "".join(b).strip()):
                if message[-1][0] == a:
                    message[-1][1].extend(b)
                else:
                    message.append([a, b])
            else:
                message.append([a, b])
        for i in range(len(message)):
            message[i][1] = "\n".join(message[i][1])

class WeChat:
    """
    负责与微信相关的功能。 windows Only
    """
    _re_get_WeChat_pid = re.compile(r"(WeChat(?:App)?)\.exe +([0-9]+)")
    show_events = {"最大化":3,
                   "最小化":2}
    def __init__(self):
        self.CONFIG = {}
        self.refresh_config()
    
    def refresh_config(self):
        with open(get_config_paths("WeChat"), "r", encoding= "utf-8") as fp:
            self.CONFIG.update(yaml.load(fp))
    @property
    def StructuralPositions(self) -> dict:
        "配置中的“结构位置”项。用于便捷获取。"
        return self.CONFIG["StructuralPositions"]
        
    def get_sys_ps_info(self):
        '''
        获取系统进程信息。
        返回：
            list
            "{Image Name}, {PID}, {Session Name}, {Session}, {Mem Usage}"
        '''
        #通过shell指令获取进程信息，此命令仅限windows。
        r = subprocess.run("tasklist", shell = True,
                           capture_output = True, timeout = 1)
        r.check_returncode()
        
        return r.stdout.decode().splitlines()[6:]
    
    def app_is_running(self):
        '''
        判断微信是否正在运行。
        
        返回：
            未运行：False
            运行中：{"WeChat":{PID}, "WeChatApp":{PID}}
        '''
        r = []
        for text in self.get_sys_ps_info():
            m = self._re_get_WeChat_pid.match(text)
            if m:
                r.append(m.groups())
        if r:
            return {k:int(p) for k, p in r}
        return False
    def is_the_foreground(self):
        '''
        判断微信界面是否是用户使用的窗口。
        
        返回：
            是： 窗口标题，
                如"微信"(微信主界面)
            否： False
        '''
        #调用win api 获取使用中的窗口句柄。
        HWND = win32gui.GetForegroundWindow()
        #获取窗口进程号。
        window_pid = win32process.GetWindowThreadProcessId(HWND)[1]
        #获取进程名称
        name = psutil.Process(window_pid).name()
        #通过进程名称判断进程是否属于微信应用
        if name == "WeChat.exe":
            return win32gui.GetWindowText(HWND)
        else:
            return False
    
    def show_WeChat(self, key = "app", event = "最大化"):
        """
        操控微信界面。未找到界面释放`AppError`。
        此方法不会将界面置顶。请使用`maximize_WeChat`最大化界面。
        
        key   : "app"最大化微信主界面，
                "web"最大化微信浏览器。
        event : "最大化"/"最小化"/其他窗口代码。
        """
        #调用windows api，通过窗口类名获取窗口句柄。
        #不排除哪天微信更换了窗口名称，因此可通过更改配置替换窗口名。
        handle = win32gui.FindWindow(self.CONFIG["window_name"][key], None)
        if handle:
            ##调用windows api，调整窗口状态。
            win32gui.ShowWindow(handle,
                                self.show_events.get(event, event))
            win32gui.UpdateWindow(handle)
            
        else:
            raise AppError("未找到界面。请确保微信已启动并登录。")
    def open_app(self):
        '打开微信，需要微信应用路径正确。'
        os.startfile(self.CONFIG["app_path"])
    
    def maximize_WeChat(self):
        '''最大化微信，此方法会将界面置顶。
        若微信未启动或未登录，则释放AppError。'''
        if not self.app_is_running():
            raise AppError("微信未启动。")
        self.open_app()#这步是置顶界面的关键。
        time.sleep(0.3)
        self.show_WeChat()
    
    def enter_menu(self, button):
        """
        自动调出微信，进入button对应的界面。
        button 应为
        '头像', '小程序面板', '收藏', '朋友圈', '聊天', '通讯录' ...
        等结构中的一个。
        """
        pos = self.StructuralPositions[button]
        if self.is_the_foreground() != self.CONFIG["main window text"]:
            self.maximize_WeChat()
        click_from_pixel_pos(pos)
    
    @classmethod
    def analysis_left_menu_positions(cls, arr : np.array, step = 1): 
        '''
        获得左菜单栏的坐标。
        arr应为np.array(img)。
        step是步长，体现为精准度，数字越小，精度越大。
        原理为微信菜单栏为黑色，联系人框为灰白色。此函数会检测颜色所占比例。
        
        返回：
            (x长度，y长度)
        '''
        #step 1:分析x轴长（起始点是0）。
        r_x = 0
        for num in np.mean(arr[0, ::step, :], 1):
            if num > 200:
                break
            r_x += step
        #step 2:分析y轴长。
        r_y = arr.shape[0]
        for num in np.mean(arr[::step, 0, :][::-1], 1):
            if num < 200:
                break
            r_y -= step
        return r_x, r_y
    def analysis_menu_buttons_positions(self, arr):
        """
        利用微信菜单背景颜色的特点，分析出按键位置。
        arr -> np.array
               应为剪辑后左侧菜单图像的数字。
        """
        ls = []
        bg = self.CONFIG["left menu bgcolour"]
        for index, row in enumerate(arr):
            if (row != bg).any():
                ls.append(index)
        
        x = arr.shape[1] // 2
        sta = last = ls[0]
        pos = []
        for i in ls[1:]:
            if i - last > 16:
                pos.append([x, (sta+last)//2])
                sta = i
            last = i
        
        names = self.CONFIG["menu button names"]
        if len(pos) == 10:
            return dict(zip(names, pos))
        elif len(pos) == 9:
            #可能头像和底色一样。
            names.pop(0)
            return dict(zip(names, pos))
        else:
            raise NotFoundError("按键搜索错误")
    def analysis_search_box(self, arr = None):
        """获取联系人搜索框输入位置。
        返回：
            tuple (x:int, y:int)
        """
        if arr is None:
            self.enter_menu("通讯录")
            button_clicked_delay()
            bg = self.CONFIG["search box bgcolour"]
            arr = np.array(sys_screen.screenshot())#截屏并转换为数组
        a = np.where(#寻找获得相等元素的索引
            np.apply_along_axis(np.all, 1,#判断哪些颜色与搜索区背景色相等
                arr#相当于对截图横着切一刀，只取最上方一小条
                [0, :, :] == bg)
            )[0]
        #因为搜索输入区占了很大比例，所以搜索区坐标平均值一定位于输入区之内
        x = (a[0] + a[-1]) // 2
        
        arr = arr[:arr.shape[0] // 3, x, :]#竖着切
        a = np.where(np.apply_along_axis(np.all, 1, arr==bg))[0]#梅开二度
        #此时a类似： [0, 1, 2, 10, 11, 12, 20, ...]
        #需要找到第一个“突跃”
        for i, v in enumerate(a):#a一定是0开头
            if i != v:
                break
        y = (i-1 + v) // 2
        return int(x), int(y)
    
    def save_config(self):
        with open(get_config_paths("WeChat"), "w", encoding= "utf-8") as fp:
            yaml.dump(self.CONFIG, fp)
    
    def auto_refresh_structural_positions(self, close_wechat = True):
        """自动更新结构位置。"""
        self.maximize_WeChat()#打开并最大化微信
        # time.sleep(0.3)
        
        #---- 获取菜单栏各按键的坐标 ----
        arr = np.array(sys_screen.screenshot())#截屏并记录为数组
        #分析微信菜单栏的位置
        x, y = self.analysis_left_menu_positions(arr)
        #获取各个按键位置
        new = self.analysis_menu_buttons_positions(arr[:y, :x, :])
        #“缓存”数据
        self.StructuralPositions.update(new)
        
        #---- 获取联系人搜索输入位置 ----
        new["friend search pos"] = list(self.analysis_search_box())
        
        #更新并保存。
        try:
            self.StructuralPositions.update(new)
            print(new)
        finally:
            self.save_config()
        #是否关闭微信界面
        if close_wechat:
            type_key(keyboard.Key.esc)
    
    def enter_chating(self, friend_name):
        '''搜索联系人，并进入第一个搜索结果。'''
        self.enter_menu("通讯录")
        button_clicked_delay()
        click_from_pixel_pos(self.StructuralPositions["friend search pos"])
        button_clicked_delay()
        pyperclip.copy(friend_name)
        type_key(keyboard.Key.ctrl, "v")
        button_clicked_delay()
        type_key(keyboard.Key.enter)
    def analysis_chat_input_box(self, arr):
        """分析联系人界面输入框的位置。输入框内必须没有草稿。
         Input:(arr: np.array)
        返回：
            tuple (int, ...) :
                (左上角x, 左上角y, 右下角x, 右下角y, 输入框工具栏y)
        """
        x_s, y_s = self.StructuralPositions["friend search pos"]
        x_s = x_s * 2 + 1
        y_s = y_s * 2 + 1
        #复制自方法`analysis_left_menu_positions`，用于查找系统菜单位置
        y_e = arr.shape[0]
        for num in np.mean(arr[::16, 0, :][::-1], 1):
            if num < 200:
                break
            y_e -= 16
        
        arr = arr[y_s:y_e, x_s:, :]
        #逐行判断颜色的丰富程度。
        colours = np.std(arr.sum(2), 1)
        
        #列表中的行号对应的行仅有单一颜色
        whe = np.where(colours==0)[0]
        #如图：
        #   |@@@@@@@@@@@| 0
        #   |abcd       | 1
        #   |@@@@@@@@@@@| 2
        #   |@@@@@@@@@@@| 3
        #   |xyz        | 4
        #   |%%%%%%%%%%%| 5
        #
        #whe 记录着[0, 2, 3, 5]这些仅有单一“颜色”行(row)的行号(line number)
        #ind 的值是whe的一个索引，对应着“单一色块”间最后一个间隔前的行号。
        #由测试得，最后两个“单一色块”间为输入框工具栏。
        ind = np.where(np.subtract(whe[1:], whe[:-1]) > 1)[0][-1]
        y = y_s + int(whe[ind + 1])
        tool_y = y_s + int(whe[ind])
        
        x = x_s + arr.shape[1]
        return x_s, y, x, y_e, tool_y
    def copy_chating_message(self, arr=None, keep_time=2):
        '''在联系人界面获取并复制聊天记录，返回复制到的文本。无法获取表情信息。
        该联系人输入界面必须没有草稿。
        arr : 截屏数组。None将自动截屏。
        keep_time : int/float 当鼠标移动到预定位置后，停留的时间。
                              数值越大，复制到的记录约多。
        '''
        if arr is None:
            arr = np.array(sys_screen.screenshot())
        _, b, a, _, t = self.analysis_chat_input_box(arr)
        b = 2 * t - b
        to_x, _ = self.StructuralPositions["friend search pos"]
        a = a - to_x
        
        #滑动鼠标
        x, y = a, b
        sub_x = (a-to_x)/10
        if not sub_x.is_integer():
            sub_x = int(sub_x)+1
        sub_y = (b-1)/10
        if not sub_y.is_integer():
            sub_y = int(sub_y)+1
        with mouse.Controller() as ctrl:
            ctrl.position = (x, y)
            ctrl.press(mouse.Button.left)
            for _ in range(10):
                x -= sub_x
                y -= sub_y
                mouse_click_delay(0.2)
                ctrl.position = (x, y)
            try:
                time.sleep(keep_time)
            finally:
                ctrl.release(mouse.Button.left)
        #复制得到聊天信息
        type_key(keyboard.Key.ctrl, "c")
        keyboard_type_delay()
        return pyperclip.paste()
    
    def get_chatting_records_from_name(self, name, keep_times=2,
                                       friend_names=None):
        """通过联系人名称获取与其聊天记录。由于技术原因，无法获取表情信息。
        name : str 联系人名称
        keep_times : 当鼠标到达顶部后保持的时间。
        friends_names : 获取信息文本后`ChatRecord`的`name`值。None/str/iterable
        """
        self.enter_chating(name)
        button_clicked_delay()
        text = self.copy_chating_message(None, keep_times)
        return ChatRecord(text, friend_names)
    def send_message(self, text, press_shift=False, arr = None):
        """在联系人界面，向对方发送消息。输入框内必须没有草稿。
        press_shift -> True   按 shift+enter 发送
                       False  按 enter 发送
        arr -> 截屏数组。None将自动截屏。
        """
        if arr is None:
            arr = np.array(sys_screen.screenshot())
        pyperclip.copy(text)
        a, b, c, d, _ = self.analysis_chat_input_box(arr)
        click_from_pixel_pos((a+c)/2, (b+d)/2)#点击输入框中央
        button_clicked_delay()
        type_key(keyboard.Key.ctrl, "v")
        keyboard_type_delay()
        if press_shift:
            type_key(keyboard.Key.shift, keyboard.Key.enter)
        else:
            type_key(keyboard.Key.enter)

class TengMeeting:
    def __init__(self):
        self.CONFIG = {}
        self.refresh_config()
    def refresh_config(self):
        with open(get_config_paths("TengMeeting"),"r",encoding="utf-8") as fp:
            self.CONFIG.update(yaml.load(fp))
    def open_app(self, check_and_wait=True, timeout=10):
        """根据配置文件给出的路径打开应用。
        check_and_wait : bool 阻塞直到当前窗口被判断为腾讯会议"""
        os.startfile(self.CONFIG["app_path"])
        if check_and_wait:
            sta = time.time()
            while not self.is_foreground():
                if timeout is not None and (time.time() - sta > timeout):
                    raise TimeoutError
    def is_foreground(self):
        """通过进程名判断当前顶端窗口是否为腾讯会议。
        返回：
            是： str 窗口名
            否： bool False
        """
        #调用win api 获取使用中的窗口句柄。
        HWND = win32gui.GetForegroundWindow()
        #获取窗口进程号。
        window_pid = win32process.GetWindowThreadProcessId(HWND)[1]
        #获取进程名称
        try:
            name = psutil.Process(window_pid).name()
        except psutil.NoSuchProcess:#某些情况下会找不到进程。这时递归重试。
            return self.is_foreground()
        #通过进程名称判断进程
        if name == "wemeetapp.exe":
            return win32gui.GetWindowText(HWND)
        else:
            return False
    def get_app_screenshot_arr(self) -> np.ndarray:
        """获取应用截图，并转换为 np.array。
        原理：获取屏幕截图，然后根据 win api 提供的函数获取窗口坐标，然后裁剪图片。
        返回：
            numpy.ndarray
        """
        self.open_app()
        HWND = win32gui.GetForegroundWindow()
        img = sys_screen.screenshot()
        img = img.crop(sys_screen.get_window_rect(HWND))
        return np.array(img)
    def get_close_cross_btn(self, arr=None):
        """获取退出键（叉号）的坐标。
        """
        if arr is None:
            arr = self.get_app_screenshot_arr()
        #基本原理仍是寻找指定颜色。
        col = self.CONFIG["btn col"]["base ctrl"]
        #TODO：重难点是通过位置识别出叉号。
        #这个是关键颜色的二维坐标
        #0 1 2 3 4 5 6 7 8
        #1         @   @
        #2           @
        #3         @   @
        #4
        #5
        idx = (arr == col).all(2)